Skip to content

Classes!!#191

Open
andyfriesen wants to merge 16 commits intomasterfrom
syntax-classes
Open

Classes!!#191
andyfriesen wants to merge 16 commits intomasterfrom
syntax-classes

Conversation

@andyfriesen
Copy link
Copy Markdown
Collaborator

Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md

The action of a class definition statement is to allocate the class object, define its functions and properties, and freeze it. Consequently, a class cannot be instantiated before this statement is executed.

We do, however, *hoist* the class identifier's binding to the top of the script so that it can be referred to within functions or classes that lexically appear before the class definition. This makes it easy and straightforward for developers to write classes or functions that mutually refer to one another.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Class hoisting implies the following code is valid:

local function getFoo()
    local f = Multiple.new(2)
    return f
end

print(getFoo()) -- ???

local function getData(factor: number)
    return 123 * factor
end

class Multiple
    public factor: number

    function length(self)
        return getData(factor)
    end
end

What is the expected behavior where getFoo() is called? A runtime error?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. It's a runtime error. Multiple will have the value nil if getFoo is invoked before the class statement is evaluated. The RFC describes this a little bit further into the document.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am completely against introducing any hoisting to the language, with this case being a perfect example of why.

If someone needs to reference a class before it is defined, they should use a forward-declared variable as a surrogate to explicitly show that they are using a token that may or may not be defined yet.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the presence of hoisting seems to be part of the reason why class definitions aren't allowed outside the top scope, since hoisting scoped variables can get messy. I would wager everyone would prefer to have scoped classes over class hoisting if they had to choose between the two.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without hoisting, there is no way at all to write two classes that mutually refer to one another.

The top-level constraint is about establishing that there is exactly on instance of every method of every class. This makes it significantly easier for us to do static method dispatch.

I'll update the RFC to clarify this. Thanks!

Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's nothing wrong here. Classes as a statement has zero code to execute outside of the initialization. Fields do not have an initializer. Just don't let functions or locals reference any classes that are defined later.

local function foo()
  return Foo.new()
  --     ^^^ `Foo` is not bound in scope here
end

class Foo -- now `Foo` is bound in scope
  bar: Bar
  
  function new()
    return Foo { bar = Bar { x = 5 } }
  end
end

print(Foo.new().bar.x) -- prints 5. It's fine.

class Bar -- now `Bar` is bound in scope
  x: number
end

-- ...the rest of the module...

The right way to do the hoisting is by doing something like this:

class <unutterable-1>
  bar: <unutterable-2>
  
  function new()
    return <unutterable-1> { bar = <unutterable-2> { x = 5 } }
  end
end

class <unutterable-2>
  x: number
end

local function foo()
  return Foo.new()
  --     ^^^ `Foo` is not bound in scope here
end

const Foo = <unutterable-1>

print(Foo.new().bar.x) -- prints 5. It's fine.

const Bar = <unutterable-2>

That is, what you do is group all classes into a mutually dependent block that can reference each other unconditionally, and then and only then do you incrementally bring the name of the individual class into scope.

The reason why hoisting in JS gets so much hate is because it relies on TDZ which cannot be analyzed at compile time, that's literally why TDZ exists at runtime. Here, we can warn when attempting to reference Foo that is not yet bound in scope. You have a warning, so this isn't hoisting in the same sense as JS hoisting.

Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, the RFC as is does phrase it like the solution is either-or: 1. no way for two classes to be defined out-of-order or mutually dependent in some way, or 2. do TDZ and say all class identifiers are hoisted to the top.

The solution I suggested avoids this problem, as long as classes do not have any initializers.

Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sigh. Flip the direction. Functions in class definitions can capture locals. TDZ.

class Bar
  function run_foo()
    Foo.print_x()
  end
end

Bar.run_foo()

local x = 5

class Foo
  function print_x()
    print(x)
  end
end

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you're describing is an explicit phrasing of exactly what the RFC proposes except that we lose the constness of the class object. Prior to the definition of ClassB, its name is in scope but has the value nil.

It's not the best but I don't see a viable alternative.

I also don't know if it's really all that bad: It only arises when code is interleaving class definitions and executing imperative actions at the module scope.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...So class variables are effectively globals?

If that's the case, how do shadowing and global variables behave with class definitions?

local A = 1 -- shadows A?
print(A) -- 1 or nil?
class A end
print(A) -- class A?

B = 2 -- global variable or parse error from reassignment?
print(B) -- 2 or nil?
class B end
print(getfenv()["B"]) -- 2?

Or what if the module returns early before a class is initialized?

class A
	function f()
		return B {} -- 50/50 chance always nil?
	end
end

if math.random(0, 1) == 1 then return end

class B end

Even if a set of consistent rules can be made, it just seems like a lot of footguns are going to be caused by this.

Comment thread docs/syntax-classes.md Outdated

Reading or writing a nonexistent class property raises an exception. This makes it easy to disambiguate between a nonexistent property and a property whose value is nil.

The builtin `type()` and `typeof()` functions return `"object"` for any class instance. We chose this over having them return the class name because class names do not have to be globally unique (they must only unique within a single module) and because we do not want to make it possible for classes to impersonate other types.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've always interpreted type to be the safe one that tells me the true type and typeof to be the one that might lie to me about the actual type. Any reason we wouldn't also do that here? Alternatively, what if typeof returned as a second argument, the pointer to the class?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main problem I want to really address is that type and typeof both work with strings.

Class names aren't required to be globally unique, so you could get wedged into a bad situation if a class happens to be unfortunately named.

instanceof is designed to solve this problem by working with class objects directly.

Copy link
Copy Markdown
Contributor

@bradsharp bradsharp Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to avoid code where I am mixing typeof, instanceof, and getmetatable (for table-based classes). Maybe it's not actually that bad in practice - but it would be nice to have a single method that tells me about somethings type.

Additionally, it can be nice to use these methods to 'inspect' the type. With instanceof I must enumerate all possible types the object can be. Sometimes I might want to write:

local supportedTypes = {
  [FooClass] = true,
  [BarClass] = true,
  [BazClass] = false,
}

-- Version I'd like to write
function isSupportedType(object)
  local T = typeof(object) -- May not be the exact statement you'd write
  return supportedTypes[T]
end

-- Version I'd need to write with current proposal
function isSupportedType(object)
  for T, supported in supportedTypes do
    if instanceof(object, T) then
      return supported
    end
  end
end

Copy link
Copy Markdown
Collaborator

@vegorov-rbx vegorov-rbx Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've always interpreted type to be the safe one that tells me the true type and typeof to be the one that might lie to me about the actual type.

This is incorrect, neither type nor typeof can lie about the type name for performance and embedder sandboxing.

Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, let's be honest. I don't think the code in the example is particularly great. Just write the return instanceof(o, FooClass) or instanceof(o, BarClass) or .... Dumb code is good code.

Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md

```luau
class Point
public x: number
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way for me to define a static member such as Point.zero?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet. It's a good idea for an extension though.

Comment thread docs/syntax-classes.md
@Dekkonot
Copy link
Copy Markdown
Contributor

Dekkonot commented Apr 6, 2026

The existence of a new VM type raises a question for the type checker. Obviously, class MyClass end will give us a type named MyClass but will there be a new built-in object type?

I imagine the use would mostly be for inverting type requirements to accept anything that wasn't an object (for e.g. serializing data) which might be niche but it's still worth considering in my opinion.

Comment thread docs/syntax-classes.md Outdated
@deviaze
Copy link
Copy Markdown
Contributor

deviaze commented Apr 6, 2026

They can only be written at the topmost scope.

Why limit class bindings to top-level when they're not exported? I've wanted a lightweight class abstraction in a local function before.

Local, function, and type bindings can be defined in any scope, therefore it'd make sense to allow the same for classes.

@TenebrisNoctua
Copy link
Copy Markdown

Certainly an interesting RFC so far. A couple of questions:

Do we need access specifiers in Luau? Majority of the code written today do not need private fields, and since classes can capture up-values, do we truly need private fields? And since private fields are not going to be supported out of the box, it seems a little weird to include the public keyword at the moment. Perhaps for now, Luau can treat a field as public by default, and if an RFC for private passes, one could explicitly define public or private.

Why create a whole new type in the VM? Couldn't a syntax sugar be implemented in place for table objects with a metatable instead? It seems unnecessary to me.

Also, I believe inheritation will be important, as at the moment, you can already create classes with a lot of functionality and decent support for the type-checker (except for shared-self types, which should be coming eventually!), but when it comes to inheritation, things get rather difficult.

@bradsharp
Copy link
Copy Markdown
Contributor

@deviaze would you be able to give an example of this?

I've wanted a lightweight class abstraction in a local function before.

@deviaze
Copy link
Copy Markdown
Contributor

deviaze commented Apr 6, 2026

@bradsharp

@deviaze would you be able to give an example of this?

I've wanted a lightweight class abstraction in a local function before.

Sure!

Here, the Dependency class is only relevant within its enclosing method (and would be a lot more readable as a class here).

https://github.com/deviaze/touchpaddy2/blob/025f0db3a7962f3297cae1817e618f12ffd7e951/src/platform.luau#L47

Comment thread docs/syntax-classes.md Outdated
@lewisakura
Copy link
Copy Markdown

This doesn't seem to be stated in the RFC, but if public is the only privacy modifier right now and private is coming later, what will the default privacy be in the future when private does get added?

@cheesycod
Copy link
Copy Markdown

What does the C API look like for classes?

Comment thread docs/syntax-classes.md
```luau
class Point
public x: number
public y
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can class fields have default values?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet. Sounds like a nice extension though.

@Bottersnike
Copy link
Copy Markdown

Just had a quick skim, but I noticed __index isn't allowed. Is there any provision for __index-like behaviour (maybe exclusively if it doesn't match any defined fields+methods)?

Comment thread docs/syntax-classes.md Outdated

Also, frankly, its worth as a programming technique is controversial: the [Fragile Base Class Problem](https://en.wikipedia.org/wiki/Fragile_base_class) can cause significant harm to a project.

Lastly, Luau easily supports interface inheritance through its structural type system, so inheritance is judged to be lower priority.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Luau does not support interface inheritance through the structural type system for any type that has methods because method types are not expressible in the type system. Examples:

type interface = { foo: (self: interface) -> () }

type something = { value: number, foo: (self: something) -> () }

local function fn(obj: interface)
	obj:foo()
end

local function create(): something
	return {
		value = 0,
		foo = function(self: something)
			print(self.value)
		end,
	}
end

fn(create()) -- type error!
type interface = { foo: <self>(self: self) -> () }

type something = { value: number, foo: (self: something) -> () }

local function fn(obj: interface)
	obj:foo()
end

local function create(): something
	return {
		value = 42,
		foo = function(self: something)
			print(self.value)
		end,
	}
end

fn(create()) -- type error!
type interface = { foo: <self>(self: self) -> () }

local function fn(obj1: interface, obj2: interface)
	obj1.foo(obj2) -- runtime error!
end

There are other avenues, but I believe I have explored them all, and it seems that there is no way to express interfaces with methods in Luau in a way where the type system gives no false positives and where it also catches common runtime errors. And this is without getting into generic interfaces.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. Our current structural type system can't capture this.

At first, I thought this could be resolved via read-only table properties, but that's not sufficient:

type interface = { read myMethod: (interface) -> () }
type impl = { value: number, read myMethod: (impl) -> () }

function accept(i: interface) i:myMethod() end
local o: impl = nil :: any
accept(o)

In this code fragment, the call to accept requires that impl <: interface, but that subtyping test requires that (impl) -> () <: (interface) -> (). Function argument types are tested contravariantly, so we also require that interface <: impl and we fail.

I think the way we solve this is by adding a special self type to the language that would allow the above fragment to typecheck. I'll tee up a separate RFC to tackle this.

Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md Outdated

#### Class Objects

The action of evaluating a class definition statement introduces a *class object* in the module scope. A class object is a value that serves as a factory for instances of the class and as a namespace for any functions that are defined on the class.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are class objects values? Can they be passed around? What is their type? What happens if you pass a class object into instanceof or type?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Class objects are values that mostly behave the same as class instances.

They are not considered to be members of any class type, so instanceof will always return false.

This could change later if we wanted to introduce a type hierarchy a la Python.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should instanceof(MyClass, class) return true (assuming we introduced a class lib)

@jackdotink
Copy link
Copy Markdown
Contributor

Looks pretty great, just some details that need to be ironed out in the RFC process!

@ishtar112
Copy link
Copy Markdown

ishtar112 commented Apr 6, 2026

I'm on board insofar as polishing OOP in Luau, but some of the specifics of this specific proposal leave me feeling a bit iffy.

The initial lack of support for private fields and methods is disappointing — understandable from the perspective of how the RFC describes the complications, but disappointing nonetheless. My concern with its omission pertains largely (if not entirely) to syntax and readability. Assuming users do not subscribe to prefixing bindings with _ to denote privacy, the lack of support for private members is likely to bring about a new category of visually messy code that the RFC otherwise does very well to amend.

There is "tension" between the syntax for fields and methods that we could honestly do better about. If functions are public by default, why do fields need to be declared with access modifiers? I'm also of the opinion that field should be a mandatory part of declaring fields: public field foo feels more in line with public function foo(), and, in combination with fields hypothetically no longer needing to be declared with an access modifier, field foo would feel more in line with function foo(). It seems an interesting decision that the keyword field is described as a mutually exclusive alternative to public/private instead of as something that works with public and private like function is already intended to.

@deviaze
Copy link
Copy Markdown
Contributor

deviaze commented Apr 6, 2026

I don't think public nor private by themselves are great indicators of something being a field. It might be more clear to C++ or C# users, but I feel it'd be quite unfamiliar to Luau users.

Even if a field keyword doesn't have prior art in other languages, it makes more sense to include it for readability reasons:

class Cat
    field name: string
    private field internal_id: number
    
    function meow(self, text: string)
    end
end

In my opinion, class fields without a private qualifier should be public by default since that's the default for fields in a Luau table.

@Ukendio
Copy link
Copy Markdown

Ukendio commented Apr 6, 2026

I find the rationale on implementing visibility modifiers being in the vein of "may as well because we have to introduce some keyword so it doesn't look weird" to be very weak. This just seems like a very bad way to decide on language features.
Just a suggestion that was not mentioned but an unannotated field could just be t1: _ or if you are going to separate fields by whitespace in the grammar anyways, just allow it to be blank such as t2: . Or why not simply enforce they have to use types? We are talking about a feature being implemented post-type-system, I don't see why the language has to bend over backwards for people who don't want to use the strict type system.

Back on visibility modifiers, I think that if the goal is to avoid unwanted access, then there are better ways that already exist at the API or representation by putting that state behind handles, whether they are opaque references or simple indices into other structures. There does not need to be some keyword that adds a whole charade to how that data is being accessed that can ultimately be bypassed ostensibly.

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Certainly an interesting RFC so far. A couple of questions:

Do we need access specifiers in Luau? Majority of the code written today do not need private fields, and since classes can capture up-values, do we truly need private fields?

Roblox has, in the past, released APIs written in Luau using conventions like _foo to denote private fields. People inevitably write code that directly accesses those private fields and now they are essentially part of the public API even though they were never meant to be.

Something more robust is needed. I'm working on a separate RFC to get into the details of this.

Why create a whole new type in the VM? Couldn't a syntax sugar be implemented in place for table objects with a metatable instead? It seems unnecessary to me.

We think there's a performance win to be had here: Class instances don't need the array part of a table, and we think that we can save some memory and improve cache locality by splitting the property hash table. The class object holds the set of keys in a particular class. The class instances just hold an array of values.

Also, I believe inheritation will be important, as at the moment, you can already create classes with a lot of functionality and decent support for the type-checker (except for shared-self types, which should be coming eventually!), but when it comes to inheritation, things get rather difficult.

Shared-self unfortunately did not work out. I spent a couple of months trying to implement it, and it turned out to be a lot more brittle than I had hoped. I cover this briefly in the RFC.

@TenebrisNoctua
Copy link
Copy Markdown

Roblox has, in the past, released APIs written in Luau using conventions like _foo to denote private fields. People inevitably write code that directly accesses those private fields and now they are essentially part of the public API even though they were never meant to be.

I still don't think this issue necessitates the implementation of access specifiers. Like I mentioned, an upvalue system could just be preferred instead.

class A
    function new()
         local privateField = 0
         local object = A()
         object.getter = function()
             return privateField
         end
         return object
    end
end

I believe a solution like this would be a better workaround. Of course, if private fields could be optimized somehow by the compiler, many people would be more okay with it.

That aside, I do believe the reasoning as to why public and private exists is weak. No keyword looks great, and if we're still going to have keywords for fields, then it should be made with a keyword like "field".

Shared-self unfortunately did not work out. I spent a couple of months trying to implement it, and it turned out to be a lot more brittle than I had hoped. I cover this briefly in the RFC.

That really sucks. Custom class modules perhaps would have benefitted from it. However, native classes in Luau sounds and works as a better option. I do look forward to potential optimizations, describing some of them would give this RFC a better foundation to work with.

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Why limit class bindings to top-level when they're not exported? I've wanted a lightweight class abstraction in a local function before.

This restriction could be lifted someday. It's in place now just to keep things simple.

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Just had a quick skim, but I noticed __index isn't allowed. Is there any provision for __index-like behaviour (maybe exclusively if it doesn't match any defined fields+methods)?

This is probably okay to add at a later date. It requires some extra complexity in the VM, but it should be fine.

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

The only reason I'm adamant about the parentheses is because unlike conventional OO languages, the fields of Luau classes are the constructor and have to be initialized. The standard syntax for fields doesn't reflect that since most languages like C# or JavaScript just treat their fields as zero-initialized/undefined until the constructor assigns a new value to them. Luau classes don't have the same mechanics, so the syntax is just a bad fit.

Mirroring a function call with the prior art of "default constructors" makes it very clear that fields are also the constructor and have to be initialized with something. It also neatly separates the instance fields from the class body, which opens up the possibility of adding other constructs like static fields to the class body easily:

class Person(name: string, id: number)
	local currentId = 0 -- static field
	
	function new(name: string)
		currentId += 1
		return Person(name, currentId)
	end
end

I'd like to see how static fields could be done in a cleaner way than this.

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

@alexmccord Also, just saying that making the constructor a normal function call solves your issue with missing keys since function arity isn't affected by nil:

local x = Name("Foo", "Bar", "Baz") -- valid, 3 args
local y = Name("John", nil, "Doe") -- valid, 3 args
local z = Name("Jane", "Doe") -- invalid, 2 args

@Cooldude2606
Copy link
Copy Markdown

I like the idea behind having fields "like function parameters" but do not like ordered parameters for construction. I have found these to be quite fragile when working with classes with a large number of fields, and always resulted in me creating a "from_table" static method.

It would be interesting syntax, possibly to extend to functions, where they can be defined using {} which is sugar for having a single table argument with the given keys. This mirrors how {} can call a function with the first argument as a table.

If that sounds like a useful extension then we could at first use () for classes, and later introduce {} for both classes and functions.

Mostly just food for thought.

function foo{ name: string, reps: number | nil }
    for i = 1, reps or 1 do
        print("Hi " .. name)
    end
end

class Cat{ name: string }
    function speak(self)
        print(self.name)
    end
end

foo{ name = "Alice", reps = 5 }
local bob = Cat{ name = "Bob" }

@Rob07erto
Copy link
Copy Markdown

I feel like () this should be the case

class Cat( name: string )
function speak(self)
print(self.name)
end
end

function foo( name: string, reps: number | nil )
for i = 1, reps or 1 do
print("Hi " .. name)
end
end

And luau do a python with named variables

foo( name = "Alice", reps = 5 )
local bob = Cat( name = "Bob" )

Comment thread docs/syntax-classes.md Outdated
@gaymeowing
Copy link
Copy Markdown
Contributor

Could this RFC be merged because its beeb 2 weeks with all of the major issues resolved? As I'd like to include class properties in #147

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Yeah. I think this is just about ready to go.

Unless something surprising happens, I'll merge this RFC on Monday the 27th.

Thanks so much for the detailed feedback everyone!

@karl-police
Copy link
Copy Markdown
Contributor

function __tostring(self)
    return `Point \{ x = {self.x}, y = {self.y} \}`
end

What if I don't want to type function __tostring(self) for every function?

Like, I am looking at the above, and there's not even colors there. And if I'd copy paste this to someone, would they even know that this is for a class, if I didn't even include the portion there

I wish there was some keyword

function class:__tostring()
    return `Point \{ x = {self.x}, y = {self.y} \}`
end

So, I could just do this.

This doesn't mean the class is named class. class: is some kind of keyword that says that this just auto emits self.. idk

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

I cannot support merging if hoisting the class identifier is still planned. I don't care if everything else is perfect, variable hoisting is an unconditionally terrible feature that must be avoided at all costs.

Many dynamic languages like Python and Wren don't even bother with solving the mutually dependent class problem, and the ones that do like JavaScript solve it by invoking variable hoisting, where TDZ ensues. One of the best things about Lua(u) was that it did local variables right and only made identifiers visible in the order they lexically appeared, because any other visibility rule would be utterly confusing. Making classes an exception ruins this aspect of the language.

Again, you can still pull this off by using an explicit forward-declared variable. It's ugly, but it's the least evil option here.

local _B
class A
	function f() return _B {} end
end

class B
	function g() return A {} end
end
_B = B

@alexmccord
Copy link
Copy Markdown
Contributor

alexmccord commented Apr 20, 2026

An alternative to hoisting is some kind of syntax for mutually dependent declarations. You already need this with mutually dependent functions (whether local function, const function, or eventually export function), and now you need this with classes.

class Foo
  function f() return Bar.f() end
end
and Bar
  function f() return Foo.f() end
end

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Python solves this problem essentially by hoisting everything.

def getX():
    return x

x = 22

print(getX())

@karl-police
Copy link
Copy Markdown
Contributor

karl-police commented Apr 21, 2026

How would this class look like https://paste.ivr.fi/irylyfegux.lua FloatVector with this or ArraySet?

Since it seems to use __index and __newindex.

If only proxies would solve it, then it would be a bit disappointing maybe.

Unless there's __cindex and __cnewindex specifically for classes idk

Comment thread docs/syntax-classes.md
Comment on lines +52 to +60
Fields are introduced with the new `public` keyword. We also plan to eventually offer `private`, but is sufficiently complex that it merits its own RFC.

Methods are introduced with the familiar `function` keyword. `public function f()` is also permitted.

Methods defined on class objects can be accessed either via `Class.method()` or `instance:method()` syntax.

If a method's first argument is named `self`, it should be invoked with the familiar `instance:method()` call syntax. This is not strictly required, but the compiler and optimizers may deoptimize code that doesn't. Type annotations on the `self` parameter are not allowed.

If a method accepts no arguments or if its first argument is not named `self`, it should be invoked via `Class.method()` syntax. This is the same as "static methods" from other languages.
Copy link
Copy Markdown

@greentheblaze greentheblaze Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't seen any explicit mentions of whether or not fields can be defined as functions. I can therefore only assume by the fact there isn't a good enough distinction between methods and fields, that defining fields as functions would currently be impossible—at least from outside constructor functions...

...but what about inside constructors? You can define callbacks in JavaScript classes like so:

class Foo {
    constructor(bar) {
        this.bar = bar;

        // Callback attribute:
        this.onJarp = () => {
            console.log(`${this.bar} yarb!`);
        };
    }

    jarp() {
        if (typeof this.onClick === 'function') {
            // Invoke the callback:
            this.onJarp();
        }
    }
}

// Usage example
const myFoo = new Foo('Bar');

// Custom callback assigned later
myFoo.onJarp = () => {
    console.log('This is a custom callback!');
};

// Trigger the callback
myButton.jarp();

Just wondering whether or not Luau could support this type of functionality. I'd prefer not to be forced to define callbacks through a method, as the use of fields and methods should convey intention about what exactly is changing about a class's instance; fields are used to house data that can be used either internally, or externally; methods are the actions that change, or are shown to "do something" to the instance. Setting a callback through a method isn't really "doing something" to the object, which is why I am not really a fan of that approach.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fields aren't immutable as far as I understand so the following should work, especially considering classes are meant to be a replacement for metatable classes.

class Foo
     public onJarp: ((foo: Foo) -> ())?
     
     function create(bar: string)
          local function onJarp(foo: Foo)
               print(`{foo.bar} yarb!`)
          end
          return Foo { bar = bar, onJarp = onJarp }
      end
      
      function Jarp(self)
           if self.onJarp then
                self.onJarp(self)
           end
      end
end

const myFoo = Foo.create("Bar")

myFoo.onJarp = function(Foo)
     print("This is a custom callback!")
end

myFoo:Jarp()

Copy link
Copy Markdown

@greentheblaze greentheblaze Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only awkward area I can still see is that there is no way to initialize functions as attributes without accidentally creating methods outside of constructors. It's annoying for one as it means that you may end up having to define the same logic multiple times throughout differing constructors (if you're going to use "static methods") that would otherwise be included in class definitions statements instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how or how complex it will be for code editors to jump to the implementation of onJarp

Comment thread docs/syntax-classes.md

Defining two classes with the same name in the same module is forbidden.

Within a class block, two declarations are allowed: Fields and methods.
Copy link
Copy Markdown
Contributor

@karl-police karl-police Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would happen if one makes a field, such as .data, which could be anything.

in the old method, you'd probably have self.data = {} and as you fill it in, in the "class.new" constructor, you'd mutate the entire object. Though I think Luau would be able to know and infer data and its mutation into object correctly?

And what about initializing functions? Some would initialize things, mid running .new()

function new(x, y)
      local newPoint = Point { x = x, y = y }
      newPoint:PrepareThings()
      return newPoint 
end

as in

function Point.new(x, y)
      local newPoint = setmetatable({ x = x, y = y }, Point)
      newPoint:PrepareThings()
      return newPoint 
end

 

And what would happen in this case here?

function new(x, y)
      local newPoint = Point { data = {} }
      newPoint.data.entry = "test"
      return newPoint 
end

Would it know about .data having entry in it? Because this is the dynamic way, on how you'd do it in the previous way. Along with any kind of table "composing" functions as well.

Comment thread docs/syntax-classes.md
Comment on lines +81 to +84
* `__idiv`

For forward-compatibility, it is a syntax error to define any other method whose
name starts with two underscores.
Copy link
Copy Markdown
Contributor

@karl-police karl-police Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see __index and or __newindex on there.

Now, I am not suggesting that if someone would re-define __index in a class, that it's like 1:1 same behavior as in a table. I am more thinking of sugar.

But I was looking for what types of classes exist, and found this special one. It seems to have self.data, it's some kind of array. It's like as if someone made something like Dictionary in C#, you'd be able to index into the object as in like dict["something"], but at the same time, you're also able to use methods dict.TryGetValue(). But since __newindex or __index isn't mentioned, you kinda can't do that?

Unless you'd make a proxy, but a proxy, is just another hack, for these 2 things. Proxies maybe are useful for other debugging. But a proxy for these two things, doesn't feel like it.

Details
--[[
    A general purpose n-dimensional vector library. mul and div support either scalar multiplication or componentwise with another vector
]]

local FloatVector = {}

type FloatVectorMembers = { data: { number } }

export type FloatVector = typeof(setmetatable({} :: FloatVectorMembers, FloatVector))

-- input: Another FloatVector or tuple of number values
function FloatVector.new(...): FloatVector
	local new = setmetatable({} :: FloatVectorMembers, FloatVector)
	new.data = {}

	local inputTable = { ... } :: any
	if #inputTable == 1 then
		if typeof(inputTable[1]) == "table" then
			if getmetatable(inputTable[1]) :: any == FloatVector then
				inputTable = (inputTable[1] :: FloatVector).data
			else
				inputTable = inputTable[1] :: { number }
			end
		end
	end

	for _, component in inputTable do
		if typeof(component) ~= "number" then
			error("arg to FloatVector.new() not a number")
		else
			table.insert(new.data, component :: number)
		end
	end
	return new
end

function FloatVector:getSize(): number
	return #self.data
end

function FloatVector.checkCompatible(lhs: FloatVector, rhs: FloatVector): boolean
	if typeof(lhs) ~= typeof(FloatVector) or typeof(rhs) ~= typeof(FloatVector) then
		error("vector operation on non-vector type")
		return false
	end

	if #lhs.data ~= #rhs.data then
		error("operating on vectors of different dimension")
		return false
	end
	return true
end

function FloatVector.__index(table, key: any)
	if typeof(key) == "number" then
		return table.data[key :: number]
	end

	return FloatVector[key]
end

function FloatVector.__newindex(table, key, value)
	if typeof(key) == "number" then
		table.data[key :: number] = value :: number
		return
	end

	rawset(table, key, value)
end

function FloatVector.__add(lhs: FloatVector, rhs: FloatVector): FloatVector
	FloatVector.checkCompatible(lhs, rhs)
	local result = {}
	for i = 1, #lhs.data, 1 do
		table.insert(result, lhs.data[i] + rhs.data[i])
	end
	return FloatVector.new(result)
end

function FloatVector.__sub(lhs: FloatVector, rhs: FloatVector): FloatVector
	FloatVector.checkCompatible(lhs, rhs)
	local result = {}
	for i = 1, #lhs.data, 1 do
		table.insert(result, lhs.data[i] - rhs.data[i])
	end
	return FloatVector.new(result)
end

function FloatVector.__mul(lhs: FloatVector, rhs: FloatVector | number): FloatVector
	if typeof(lhs) == typeof(FloatVector) and typeof(rhs) == "number" then
		local result = {}
		for _, component in lhs.data do
			table.insert(result, component * rhs :: number)
		end
		return FloatVector.new(result)
	else
		FloatVector.checkCompatible(lhs, rhs)
		local result = {}
		for i = 1, #lhs.data, 1 do
			table.insert(result, lhs.data[i] * rhs.data[i])
		end
		return FloatVector.new(result)
	end
end

function FloatVector.__div(lhs: FloatVector, rhs: FloatVector | number): FloatVector
	if typeof(lhs) == typeof(FloatVector) and typeof(rhs) == "number" then
		local result = {}
		for _, component in lhs.data do
			table.insert(result, component / rhs :: number)
		end
		return FloatVector.new(result)
	else
		FloatVector.checkCompatible(lhs, rhs)
		local result = {}
		for i = 1, #lhs.data, 1 do
			table.insert(result, lhs.data[i] / rhs.data[i])
		end
		return FloatVector.new(result)
	end
end

function FloatVector:dot(rhs): number
	FloatVector.checkCompatible(self, rhs)
	local result = 0
	for i = 1, #self.data, 1 do
		result += self.data[i] * rhs.data[i]
	end
	return result
end

-- must be a 3 dimensional vector
function FloatVector:cross(rhs): FloatVector?
	FloatVector.checkCompatible(self, rhs)
	if #self.data ~= 3 or #rhs.data ~= 3 then
		error("cross product is only defined for two 3 dimensional vectors")
		return nil
	end
	return FloatVector.new(
		self.data[2] * rhs.data[3] - self.data[3] * rhs.data[2],
		self.data[3] * rhs.data[1] - self.data[1] * rhs.data[3],
		self.data[1] * rhs.data[2] - self.data[2] * rhs.data[1]
	)
end

function FloatVector:magnitude(): number
	local sqSum = 0
	for _, component in self.data do
		sqSum += component ^ 2
	end
	return math.sqrt(sqSum)
end

function FloatVector:fuzzyEq(rhs: FloatVector, eps: number): boolean
	if not FloatVector.checkCompatible(self, rhs) then
		return false
	end
	eps = if eps then eps else 0.00001
	for i = 1, #self.data, 1 do
		if math.abs(self.data[i] - rhs.data[i]) > eps then
			return false
		end
	end
	return true
end

return FloatVector

Copy link
Copy Markdown

@greentheblaze greentheblaze Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But since __newindex or __index isn't mentioned, you kinda can't do that?](#191)

It may be coming in the future as mentioned here: #191 (comment)

@karl-police
Copy link
Copy Markdown
Contributor

karl-police commented Apr 21, 2026

What would happen if someone passed a class through networking?

Would there be a class.getData method or similar? Or would object.data.anything work?

:FireServer(myClass) what would happen if it is a "class"
The original would just send table without metatable, which is fine.
But this one?

With table fake classes, since it's already a table, it could just pass that through, right? But how fast would a class to table conversion be and etc.?

If an object has just object.data then yeah, you'd just send that through. But can you have "free tables" as fields?

@ValorZard
Copy link
Copy Markdown

Forgive me if this has already been mentioned, but will you be able to monkey patch a function or a variable onto an object?
In GDScript, I know you can do a jank form of “traits” by duck typing an object to check if it has a certain function.
(I think Go sorta does something similar with their interfaces)
Would that be possible with this proposal?

@Pyseph
Copy link
Copy Markdown

Pyseph commented Apr 21, 2026

I would really like for a second thought wrt whether to use the local keyword or not for classes. I had previously made my case above, and would love to know as to whether there is a stronger reason for going against it.

@SPY
Copy link
Copy Markdown

SPY commented Apr 21, 2026

Forgive me if this has already been mentioned, but will you be able to monkey patch a function or a variable onto an object?

You can change a variable(as soon as it is public) but not a function(method).
It is possible to define a method as a variable with function signature for such use cases, but you will pay a performance and ergonomic price, because you will need to create a fresh closure and pass it to constructor every time you create a new class instance.

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

I also would like some reconsideration on my parentheses proposal. Like I've said, the current "spreadsheet" style for fields is unideal because that syntax is only good for languages that don't make an effort to generate a default constructor from the fields. Luau does, so if this syntax stays it will become extremely awkward to add things like static fields later down the line when they sensibly shouldn't be a part of the constructor. My proposal cleanly separates constructor from class implementation, making this distinction trivial.

@alexmccord
Copy link
Copy Markdown
Contributor

alexmccord commented Apr 21, 2026

@MagmaBurnsV I don't think there's any issues there. You can think of function declarations in the class as already a static const field, e.g.

class Point
  public x: number
  public y: number

  public function components(self)
    return self.x, self.y
  end
end

This is equivalent to the following:

class Point
  public x: number
  public y: number

  public static const components = function(self: Point)
    return self.x, self.y
  end
end

(with the difference that this components is also included in the metatable, that's what inherent methods are about). And then if static fields are added, const modifier is optional. You can see how the latter example here doesn't require you to supply components every time you want to instantiate an instance of Point.

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

I was talking about static fields being included in the constructor itself. Take this example:

class Person
	public name: string
	private static nextId = 0
	public id: number
end

Does the constructor include nextId? Hopefully not, but this is special cased and isn't made clear in the syntax. Other languages don't have this issue because constructors aren't generated automatically from the fields.

Compare this with my version:

class Person(name: string, id: number)
	local nextId = 0
end

Now it's clear only name and id are a part of the constructor, with nextId being a static field that exists in the class body itself rather than as an instance field.

@gaymeowing
Copy link
Copy Markdown
Contributor

Shouldn't classes have a default __tostring metamethod? That prints the name of the class for nicer default debugging.

@Bottersnike
Copy link
Copy Markdown

I cannot support merging if hoisting the class identifier is still planned. I don't care if everything else is perfect, variable hoisting is an unconditionally terrible feature that must be avoided at all costs.

Many dynamic languages like Python and Wren don't even bother with solving the mutually dependent class problem, and the ones that do like JavaScript solve it by invoking variable hoisting, where TDZ ensues. One of the best things about Lua(u) was that it did local variables right and only made identifiers visible in the order they lexically appeared, because any other visibility rule would be utterly confusing. Making classes an exception ruins this aspect of the language.

Python does actually have a "solution" for this in its types, and it's so truly horrendous that I think it argues strongly in favour of at least some degree of hoisting. If a class needs to reference a class defined below itself, the type can be written as foo: "Bar" rather than foo: Bar, then those string types are run through eval() once the whole file is parsed to determine what they were meant to be. It's as fragile and awful as it sounds.

@vegorov-rbx
Copy link
Copy Markdown
Collaborator

Shouldn't classes have a default __tostring metamethod? That prints the name of the class for nicer default debugging.

Small distinction, __tostring metamethod is not required for values to have a string representation.
We can define one in general terms.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.